<!--
Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
Code distributed by Google as part of the polymer project is also
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
-->

<!--
`core-list` displays a virtual, 'infinite' list. The template inside the 
`core-list` element represents the dom to create for each list item. The
`data` property specifies an array of list item data. The `height` property
represents the fixed height of a list item (variable height list items are
not yet supported).

`core-list` manages a viewport of data based on the current scroll position.
For performance reasons, not every item in the list is rendered at once.

List item templates should bind to template models of the following structure

    {
      index: 0,         // list index for this item
      selected: false,  // selection state for this item
      model: {          // user data corresponding to data[index]
        /* user data  */
      }
    }

For example, given the following data array:

    [
      {name: 'Bob', checked: true},
      {name: 'Tim', checked: false},
      ...
    ]

The following code would render the list (note the `name` and `checked`
properties are bound from the `model` object provided to the template
scope):

    <core-list data="{{data}}" height="80">
      <template>
        <div class="{{ {selected: selected} | tokenList }}">
          List row: {{index}}, User data from model: {{model.name}}
          <input type="checkbox" checked="{{model.checked}}">
        </div>
      </template>
    </core-list>

By default, the list supports selection via tapping. Styling the selection 
should be done via binding to the `selected` property of each model.

@group Polymer Core Elements
@element core-list
-->
<link rel="import" href="../polymer/polymer.html">
<link rel="import" href="../core-selection/core-selection.html">

<polymer-element name="core-list" on-tap="{{tapHandler}}" tabindex="-1">
<template>
  <core-selection id="selection" multi="{{multi}}" on-core-select="{{selectedHandler}}"></core-selection>
  <link rel="stylesheet" href="core-list.css">
  <div id="viewport" class="core-list-viewport"><content></content></div>
</template>
<script>
(function() {

  Polymer('core-list', {
    
    publish: {
      /**
       * Fired when an item element is tapped.
       * 
       * @event core-activate
       * @param {Object} detail
       *   @param {Object} detail.item the item element
       */

      /**
       * 
       * An array of source data for the list to display.
       *
       * @attribute data
       * @type array
       * @default null
       */
      data: null,

      /**
       * 
       * An optional element on which to listen for scroll events.
       *
       * @attribute scrollTarget
       * @type Element
       * @default core-list
       */
      scrollTarget: null,

      /**
       * 
       * The height of a list item. `core-list` currently supports only fixed-height
       * list items. This height must be specified via the height property.
       *
       * @attribute height
       * @type number
       * @default 80
       */
      height: 80,

      /**
       * 
       * The number of extra items rendered above the minimum set required to
       * fill the list's height.
       *
       * @attribute extraItems
       * @type number
       * @default 30
       */
      extraItems: 30,

      /**
       * 
       * When true, tapping a row will select the item, placing its data model
       * in the set of selected items retrievable via the `selection` property.
       *
       * Note that tapping focusable elements within the list item will not
       * result in selection, since they are presumed to have their own action.
       *
       * @attribute selectionEnabled
       * @type {boolean}
       * @default true
       */
      selectionEnabled: true,

      /**
       * 
       * Set to true to support multiple selection.  Note, existing selection
       * state is maintained only when changing `multi` from `false` to `true`;
       * it is cleared when changing from `true` to `false`.
       *
       * @attribute multi
       * @type boolean
       * @default false
       */
      multi: false,

      /**
       * 
       * Data record (or array of records, if `multi: true`) corresponding to
       * the currently selected set of items.
       *
       * @attribute selection
       * @type {any}
       * @default null
       */
       selection: null
    },

    // Local cache of scrollTop
    _scrollTop: 0,
    
    observe: {
      'data template scrollTarget': 'initialize',
      'multi selectionEnabled': '_resetSelection'
    },

    ready: function() {
      this._boundScrollHandler = this.scrollHandler.bind(this);
      this._oldMulti = this.multi;
      this._oldSelectionEnabled = this.selectionEnabled;
    },

    attached: function() {
      this.template = this.querySelector('template');
      if (!this.template.bindingDelegate) {
        this.template.bindingDelegate = this.element.syntax;
      }
    },

    _resetSelection: function() {
      if (((this._oldMulti != this.multi) && !this.multi) || 
          ((this._oldSelectionEnabled != this.selectionEnabled) && 
            !this.selectionEnabled)) {
        this._clearSelection();
        this.refresh(true);
      } else {
        this.selection = this.$.selection.getSelection();
      }
      this._oldMulti = this.multi;
      this._oldSelectionEnabled = this.selectionEnabled;
    },

    // TODO(sorvell): it'd be nice to dispense with 'data' and just use 
    // template repeat's model. However, we need tighter integration
    // with TemplateBinding for this.
    initialize: function() {
      if (!this.template) {
        return;
      }
      
      // TODO(kschaaf): This is currently the only way to know that the array
      // was mutated as opposed to newly assigned; to be updated with better API
      if (arguments.length == 1) {
        var splices = arguments[0];
        for (var i=0; i<splices.length; i++) {
          var s = splices[i];
          for (var j=0; j<s.removed.length; j++) {
            var d = s.removed[j];
            this.$.selection.setItemSelected(d, false);
          }
        }
      } else {
        this._clearSelection();
      }

      var target = this.scrollTarget || this;
      if (this._target !== target) {
        if (this._target) {
          this._target.removeEventListener('scroll', this._boundScrollHandler, false);
        }
        this._target = target;
        this._target.addEventListener('scroll', this._boundScrollHandler, false);
      }
      // Only use -webkit-overflow-touch from iOS8+, where scroll events are fired
      var ios = navigator.userAgent.match(/iP(?:hone|ad;(?: U;)? CPU) OS (\d+)/);
      if (ios && ios[1] >= 8) {
        target.style.webkitOverflowScrolling = 'touch';
      }

      this.initializeData();
    },

    initializeData: function() {
      var currentCount = this._physicalCount || 0;
      var dataLen = this.data && this.data.length || 0;
      this._visibleCount = Math.ceil(this._target.offsetHeight / this.height);
      this._physicalCount = Math.min(this._visibleCount + this.extraItems, dataLen);
      this._physicalCount = Math.max(currentCount, this._physicalCount);
      this._physicalData = this._physicalData || new Array(this._physicalCount);
      var needItemInit = false;
      while (currentCount < this._physicalCount) {
        this._physicalData[currentCount++] = {};
        needItemInit = true;
      }
      this.template.model = this._physicalData;
      this.template.setAttribute('repeat', '');
      if (needItemInit) {
        this.onMutation(this, this.initializeItems);
      } else {
        this.refresh(true);
      }
    },

    initializeItems: function() {
      var currentCount = this._physicalItems && this._physicalItems.length || 0;
      this._physicalItems = this._physicalItems || new Array(this._physicalCount);
      for (var i = 0, item = this.template.nextElementSibling;
           item && i < this._physicalCount;
           ++i, item = item.nextElementSibling) {
        this._physicalItems[i] = item;
        item._transformValue = 0;
      }
      this.refresh(true);
    },

    updateItem: function(virtualIndex, physicalIndex) {
      var virtualDatum = this.data && this.data[virtualIndex];
      var physicalDatum = this._physicalData[physicalIndex];
      physicalDatum.model = virtualDatum;
      physicalDatum.physicalIndex = physicalIndex;
      physicalDatum.index = virtualIndex;
      physicalDatum.selected = this.selectionEnabled && virtualDatum ? 
          this._selectedData.get(virtualDatum) : null;
      var physicalItem = this._physicalItems[physicalIndex];
      physicalItem.hidden = !virtualDatum;
    },

    scrollHandler: function(e, detail) {
      this._scrollTop = e.detail ? e.detail.target.scrollTop : e.target.scrollTop;
      this.refresh(false);
    },

    /**
     * Refresh the list at the current scroll position.
     *
     * @method refresh
     */
    refresh: function(force) {
      // Check that the array hasn't gotten longer since data was initialized
      var dataLen = this.data && this.data.length || 0;
      if (force) {
        if (this._physicalCount < 
            Math.min(this._visibleCount + this.extraItems, dataLen)) {
          // Need to add more items; once new data & items are initialized,
          // refresh will be run again
          this.initializeData();
          return;
        }
        this._physicalHeight = this.height * this._physicalCount;
        this.$.viewport.style.height = this.height * dataLen + 'px';
      }

      var firstVisibleIndex = Math.floor(this._scrollTop / this.height);
      var visibleMidpoint = firstVisibleIndex + this._visibleCount / 2;

      var firstReifiedIndex = Math.max(0, Math.floor(visibleMidpoint - 
          this._physicalCount / 2));
      firstReifiedIndex = Math.min(firstReifiedIndex, dataLen - 
          this._physicalCount);
      firstReifiedIndex = (firstReifiedIndex < 0) ? 0 : firstReifiedIndex;

      var firstPhysicalIndex = firstReifiedIndex % this._physicalCount;
      var baseVirtualIndex = firstReifiedIndex - firstPhysicalIndex;

      var baseTransformValue = Math.floor(this.height * baseVirtualIndex);
      var nextTransformValue = Math.floor(baseTransformValue + 
          this._physicalHeight);

      var baseTransformString = 'translate3d(0,' + baseTransformValue + 'px,0)';
      var nextTransformString = 'translate3d(0,' + nextTransformValue + 'px,0)';

      this.firstPhysicalIndex = firstPhysicalIndex;
      this.baseVirtualIndex = baseVirtualIndex;

      for (var i = 0; i < firstPhysicalIndex; ++i) {
        var item = this._physicalItems[i];
        if (force || item._transformValue != nextTransformValue) {
          this.updateItem(baseVirtualIndex + this._physicalCount + i, i);
          setTransform(item, nextTransformString, nextTransformValue);
        }
      }
      for (var i = firstPhysicalIndex; i < this._physicalCount; ++i) {
        var item = this._physicalItems[i];
        if (force || item._transformValue != baseTransformValue) {
          this.updateItem(baseVirtualIndex + i, i);
          setTransform(item, baseTransformString, baseTransformValue);
        }
      }
    },

    // list selection
    tapHandler: function(e) {
      var n = e.target;
      var p = e.path;
      if (!this.selectionEnabled || (n === this)) {
        return;
      }
      requestAnimationFrame(function() {
        // Gambit: only select the item if the tap wasn't on a focusable child
        // of the list (since anything with its own action should be focusable
        // and not result in result in list selection).  To check this, we
        // asynchronously check that shadowRoot.activeElement is null, which 
        // means the tapped item wasn't focusable. On polyfill where
        // activeElement doesn't follow the data-hinding part of the spec, we
        // can check that document.activeElement is the list itself, which will
        // catch focus in lieu of the tapped item being focusable, as we make
        // the list focusable (tabindex="-1") for this purpose.  Note we also
        // allow the list items themselves to be focusable if desired, so those
        // are excluded as well.
        var active = window.ShadowDOMPolyfill ? 
            wrap(document.activeElement) : this.shadowRoot.activeElement;
        if (active && (active != this) && (active.parentElement != this) && 
            (document.activeElement != document.body)) {
          return;
        }
        // Unfortunately, Safari does not focus certain form controls via mouse,
        // so we also blacklist input, button, & select
        // (https://bugs.webkit.org/show_bug.cgi?id=118043)
        if ((p[0].localName == 'input') || 
            (p[0].localName == 'button') || 
            (p[0].localName == 'select')) {
          return;
        }

        var model = n.templateInstance && n.templateInstance.model;
        if (model) {
          var vi = model.index, pi = model.physicalIndex;
          var data = this.data[vi], item = this._physicalItems[pi];
          this.$.selection.select(data);
          this.asyncFire('core-activate', {data: data, item: item});
        }
      }.bind(this));
    },

    selectedHandler: function(e, detail) {
      this.selection = this.$.selection.getSelection();
      var i$ = this.indexesForData(detail.item);
      // TODO(sorvell): we should be relying on selection to store the
      // selected data but we want to optimize for lookup.
      this._selectedData.set(detail.item, detail.isSelected);
      if (i$.physical >= 0) {
        this.updateItem(i$.virtual, i$.physical);
      }
    },

    /**
     * Select the list item at the given index.
     *
     * @method selectItem
     * @param {number} index 
     */
    selectItem: function(index) {
      if (!this.selectionEnabled) {
        return;
      }
      var data = this.data[index];
      if (data) {
        this.$.selection.select(data);
      }
    },

    /**
     * Set the selected state of the list item at the given index.
     *
     * @method setItemSelected
     * @param {number} index 
     * @param {boolean} isSelected 
     */
    setItemSelected: function(index, isSelected) {
      var data = this.data[index];
      if (data) {
        this.$.selection.setItemSelected(data, isSelected);
      }
    },

    indexesForData: function(data) {
      var virtual = this.data.indexOf(data);
      var physical = this.virtualToPhysicalIndex(virtual);
      return { virtual: virtual, physical: physical };
    },

    virtualToPhysicalIndex: function(index) {
      for (var i=0, l=this._physicalData.length; i<l; i++) {
        if (this._physicalData[i].index === index) {
          return i;
        }
      }
      return -1;
    },

    /**
     * Clears the current selection state of the list.
     *
     * @method clearSelection
     */
    clearSelection: function() {
      this._clearSelection();
      this.refresh(true);
    },

    _clearSelection: function() {
      this._selectedData = new WeakMap();
      this.$.selection.clear();
      this.selection = this.$.selection.getSelection();
    },

    scrollToItem: function(index) {
      this.scrollTop = index * this.height;
    }

  });

  // determine proper transform mechanizm
  if (document.documentElement.style.transform !== undefined) {
    var setTransform = function(element, string, value) {
      element.style.transform = string;
      element._transformValue = value;
    }
  } else {
    var setTransform = function(element, string, value) {
      element.style.webkitTransform = string;
      element._transformValue = value;
    }
  }

})();
</script>
</polymer-element>
